Skip to content

feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth - #3777

Open
wpfleger96 wants to merge 24 commits into
mainfrom
wpfleger/admin-api-bearer-auth
Open

feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth#3777
wpfleger96 wants to merge 24 commits into
mainfrom
wpfleger/admin-api-bearer-auth

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Replaces the BUZZ_ADMIN_INSECURE_NO_AUTH / BUZZ_ADMIN_PUBKEYS model with a two-tier principal model backed by config union + DB, gated by NIP-98 HTTP auth. Phases 1 and 2 of Plan v4 (Thufir-reviewed spec).

What changed

Authentication (BUZZ_ADMIN_AUTH)

Replaces the deleted BUZZ_ADMIN_INSECURE_NO_AUTH=true variable with BUZZ_ADMIN_AUTH accepting token (default), disabled, or nip98. Startup fails closed on missing/malformed configuration.

Principal resolution (NIP-98 mode)

resolve_admin_principal() returns AdminPrincipal { pubkey, role, source }:

  • Operator/Config — pubkey ∈ RELAY_OPERATOR_PUBKEYS
  • Operator/OwnerFallback — pubkey == RELAY_OWNER_PUBKEY and RELAY_OPERATOR_PUBKEYS is empty (config-evaluated, never from runtime DB rows)
  • Operator or Moderator / Db — row in the relay_operators table
  • No match → 403

Config always outranks DB. None never falls through as a role.

Token/disabled mode

Read routes work in all modes. Mutation and staffing routes require nip98; token/disabled modes receive 403 from require_mutation_principal.

Phase 2: report resolution

POST /reports/{id}/resolve — enforcement state machine with idempotency:

  • Decision-only: dismiss/escalate — CAS open→terminal + audit row in one transaction.
  • Enforcement: delete/kick/ban/timeout — claims report (open→processing), runs durable mutation, finalizes to resolved. Crash-safe: re-drive picks up at the step marker and converges to exactly-one enforcement.

PATCH /feedback/{id} — update product_feedback.status (new|reviewed|archived). Requires nip98.

Phase 2: staffing endpoints

GET/PUT/DELETE /operators/{pubkey} — Operator-only. PUT/DELETE against a config-backed pubkey returns 409 Conflict. GET /operators returns the union of config and DB with per-entry source attribution.

Probe endpoint

GET /probe — auth-mode, role, source, canAct, canStaff discovery for the desktop console.

Migrations

  • 0029_relay_operators.sqlrelay_operators table (deployment-global; registered in _operator_global_tables), actor_authority column on moderation_actions, processing status + active_action_id on moderation_reports, status column on product_feedback.
  • 0030_relay_admin_actions.sqlrelay_admin_actions enforcement-action table with request_id idempotency key and step_marker for crash recovery.

Documentation

docs/admin/README.md rewritten to document the full principal model, NIP-98 event requirements (including query string, method tag, payload tag for body mutations), owner fallback B semantics, role/source table, capabilities by role, roster management, and startup error matrix.

Tests

915 unit tests pass. 10 ignored (requires Postgres). 7 pre-existing failures (6 media tests + 1 telemetry flake) that also fail on origin/main without a local DB.

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 30, 2026 17:30
@cameronhotchkies cameronhotchkies added the triage-ready Appropriate for agentic review label Jul 30, 2026
@wpfleger96 wpfleger96 changed the title feat(relay): require a bearer token on the admin moderation API feat(relay): add authenticated admin API with bearer-token and network-layer modes Jul 30, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch 3 times, most recently from 3fcbdc0 to d014e40 Compare July 31, 2026 19:17
kalvinnchau
kalvinnchau previously approved these changes Jul 31, 2026

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at d014e40. The fail-closed config contract, constant-time bearer validation, host/origin ordering, insecure network-boundary mode, dashboard token lifecycle, authenticated attachment fetches, and CSP/static routing are coherent and covered. Deployment dependency is external: land bb-public#339 and wait for Argo rollout before deploying this relay image.

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 8 commits August 3, 2026 14:35
The deployment-admin API at /api/admin/v1 exposed every moderation report,
product feedback submission, submitter pubkey, and attachment blob to anyone
who could reach the listener with the right Host header. Host and Origin
matching is a routing constraint, not authentication.

BUZZ_ADMIN_HOST now requires BUZZ_ADMIN_TOKEN and the relay fails closed at
startup without it, so no deployment can be upgraded into an unauthenticated
state. The credential is checked before Host and Origin so an unauthenticated
caller cannot use the response code as an oracle for the expected admin host.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dashboard keeps the operator bearer token in sessionStorage, but its
documents and assets were served with no Content-Security-Policy: the
existing policy is middleware on /api/admin/v1 only, which the SPA
fallback bypasses. A header is used rather than a meta tag because
frame-ancestors is ignored in meta. The policy is scoped to the admin
host so the public bundle is unaffected, and allows blob: images because
attachments are fetched with the token and rendered from object URLs.

The browser suite now proves the attachment object URLs are revoked on
both races (replacement/unmount and completion after unmount) and that
concurrent 401s collapse into a single re-prompt.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dashboard's security posture section covered the bearer token and the
Host/Origin defense-in-depth but not the response-header CSP that now ships
with every admin-host SPA response, leaving operators without the frame and
script-origin guarantees they are relying on.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The admin document links /favicon.svg, but the admin-host fallback routed
only /assets/*, so the icon 404'd on the one host that serves the dashboard.
Vite already emits the file at the bundle root; the fallback now admits that
exact path and nothing else, so the bundle directory stays unbrowsable.

The CSP paragraph also claimed the policy restricts every network
destination. CSP does not constrain top-level navigation, so an executing
same-origin script can still navigate with the token in a URL.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… deployments

Operators whose admin API is protected at the network layer (VPN, firewall,
private ingress) can now set BUZZ_ADMIN_INSECURE_NO_AUTH=true to disable
bearer auth while keeping Host/Origin checks as defense-in-depth. This is
the intended mode for Block's bb-public relay, which runs behind WARP+Okta.

Config semantics:
- Only the exact value "true" enables disabled mode; any other non-empty
  value is a startup ConfigError (no silent typo-coercion).
- BUZZ_ADMIN_TOKEN and BUZZ_ADMIN_INSECURE_NO_AUTH=true both set is a
  startup ConfigError (ambiguous intent).
- BUZZ_ADMIN_HOST with neither remains the existing fail-closed error.
- A prominent WARN is logged on every startup in disabled mode.

SPA probe: the dashboard now probes auth mode on first load (one
unauthenticated GET to /api/admin/v1/reports). 200 → skip prompt (relay
is in insecure_no_auth mode). Anything else → token prompt (existing
behavior). This makes the dashboard work without user interaction for
network-layer-protected deployments.

Tests added:
- Rust (config): insecure_no_auth activates, both-set fails, junk values
  fail, empty string treated as unset (4 tests)
- Rust (api::admin): disabled mode passes all routes, still rejects wrong
  host and mismatched origin (3 tests)
- Playwright (auth): probe 200 skips prompt, probe non-200 shows prompt (2 tests)

Docs: README, both CHANGELOGs, and both .env.examples rewritten to
describe the final two-mode shape once.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The JSDoc stated true = relay accepted (no auth needed) and false = 401.
The code returns true when auth is required (non-200 response) and false
when the relay returned 200 (insecure_no_auth mode, no token needed).
Correct the comment to match the implementation and function name.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
AdminConfig previously modelled the auth mode as token: Option<AdminToken>
plus insecure_no_auth: bool, leaving two invalid states representable and
bridging the gap with an .expect() on the hot request path. Replace with
an AdminAuth::Token(AdminToken) | AdminAuth::InsecureNoAuth enum so the
type system rules out the invalid states and the .expect() is deleted.

Config-parse behaviour is byte-identical: same error strings, same WARN
messages, same fail-closed matrix, same env var names. Existing tests are
updated mechanically (constructor call sites and one pattern-match assertion).

Also adds a one-sentence note to docs/admin/README.md that token-mode issues
one by-design 401 probe per fresh browser session so operators do not alert
on it as an attack.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…llowlist

Deletes BUZZ_ADMIN_INSECURE_NO_AUTH. Replaces it with BUZZ_ADMIN_AUTH, a
mode enum with three exact values:

- unset / "token"  — bearer token via BUZZ_ADMIN_TOKEN (unchanged default)
- "disabled"       — no auth; boot WARN retained (replaces INSECURE_NO_AUTH)
- "nip98"          — NIP-98 HTTP Auth via BUZZ_ADMIN_PUBKEYS allowlist

All existing token/disabled invariants carry 1:1 under the new name.
Junk values fail closed at startup with a ConfigError.

NIP-98 mode (admin-moderation scope):
- Parses BUZZ_ADMIN_PUBKEYS as comma-separated 64-hex pubkeys (deduped).
- BUZZ_ADMIN_PUBKEYS required non-empty in nip98 mode; warn-and-ignore in
  token/disabled modes. BUZZ_ADMIN_TOKEN + nip98 = ConfigError.
- authorize_nip98(): single Authorization: Nostr <base64 event> header,
  verify_nip98_event, deployment-scoped replay guard (admin-moderation),
  allowlist membership check. Uniform 401 on all failures; no oracle.
- WWW-Authenticate: Nostr on 401 (Bearer stays in token mode) — the SPA
  uses this header to discover the auth mode.
- Canonical URL: https://<BUZZ_ADMIN_HOST>/api/admin/v1<stripped-path>;
  http:// for loopback hosts (local dev). Axum strips the prefix before
  handlers; ADMIN_API_PREFIX constant re-adds it for NIP-98 verification.

SPA (admin-web):
- probeAuthMode() reads WWW-Authenticate: Bearer/Nostr/absent to return
  "token" | "nip98" | "disabled".
- nip98 mode: signNip98() helper builds kind-27235 events via window.nostr
  (NIP-07); send() attaches Authorization: Nostr per request.
- Nip07Screen: shown when nip98 mode is detected but window.nostr is absent;
  instructs operator to install nos2x or Alby.
- 401 in nip98 mode re-signs once then surfaces the error (no infinite loop).

Docs/config:
- docs/admin/README.md rewritten to three-mode shape; migration note from
  BUZZ_ADMIN_INSECURE_NO_AUTH.
- Both .env.example files updated (root and deploy/compose/).
- CHANGELOG.md Unreleased sections updated with three-mode description.

Tests:
- Config matrix: all mode values, junk, every mutual exclusion,
  missing/invalid BUZZ_ADMIN_PUBKEYS.
- NIP-98 route tests: wrong pubkey → 401, replay → 401, wrong u/method →
  401, duplicate Authorization → 401, valid → 200, valid + wrong host → 403.
- Regression pins: token-mode and disabled-mode behavior unchanged.
- Playwright: nip98 mode without NIP-07 → instruction screen; mocked
  window.nostr happy path signs requests and renders dashboard.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch from d014e40 to e93d5be Compare August 3, 2026 19:40
@wpfleger96 wpfleger96 changed the title feat(relay): add authenticated admin API with bearer-token and network-layer modes feat(relay): add authenticated admin API — bearer token, NIP-98 pubkey allowlist, and disabled modes Aug 3, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch from cd6c06f to 9d54f68 Compare August 3, 2026 20:08
…x crate changelog

Three issues from Thufir pass-1:

1. Query-bearing requests now authenticate correctly. All five handlers now
   pass uri.path_and_query() to authorize() instead of uri.path(), so the
   canonical URL includes the query string (e.g. /api/admin/v1/reports?
   status=open&limit=100) and verify_nip98_event() compares the right URL.
   Two new route tests: query-bearing request with matching full-URL event
   -> 200; path-only event for a query-bearing request -> 401.

2. Bounded NIP-98 retry is now implemented. send() in nip98 mode re-signs
   and retries exactly once on the first 401 (handles clock skew / key
   rotation); a second 401 surfaces the error. Two new Playwright tests:
   first-401-then-200 asserts two distinct auth calls and eventual render;
   persistent-401 asserts the error state (role=alert / 'Could not load
   data') after exactly two attempts.

3. crates/buzz-relay/CHANGELOG.md Unreleased section rewritten to the
   three-mode shape (BUZZ_ADMIN_AUTH=token|disabled|nip98, BUZZ_ADMIN_
   PUBKEYS). BUZZ_ADMIN_INSECURE_NO_AUTH retained only as a migration note.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Hayt and others added 3 commits August 5, 2026 17:03
…arer-auth

* origin/main: (65 commits)
  fix(desktop): route macos notification clicks (#4799)
  feat(mobile): sync themes per community (#3767)
  feat(desktop): sync themes per community (#3653)
  feat(desktop): cap OpenClaw agent parallelism at 5 (#4019)
  fix(buzz-agent): scope handoff cap per turn, not per session lifetime (#4805)
  Fix mobile message timeline bounce (#4862)
  Polish mobile bottom sheets and profile cards (#4911)
  Fix media attachment actions (#4849)
  fix(desktop): remove join API token control (#4897)
  fix(desktop): allow shared agent mentions (#4913)
  Polish mobile top navigation (#4778)
  fix(release): tag immutable desktop candidates (#4811)
  fix(channels): restrict private-channel invitations (#4612)
  fix(acp): reject unattended permission requests (#4609)
  fix(workflow): bind trigger author to the signed event (#4607)
  fix(git): revoke access for banned relay members (#4608)
  fix(agent): recover from unsupported image input instead of poisoning the turn (#4896)
  Define private managed agent wire protocol (#4593)
  fix(mobile): serialize channel sections sync (#3165)
  fix(desktop): make missing-command error actionable for released builds (#4802)
  ...

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	CHANGELOG.md
… model

Replace BUZZ_ADMIN_PUBKEYS allowlist with a typed principal model backed by
config union + DB. Implement Phase 1 of Plan v4 (Thufir-cleared spec).

## Principal resolution (fallback B)

resolve_admin_principal() returns AdminPrincipal { pubkey, role, source }:
- Operator/Config    if pubkey ∈ RELAY_OPERATOR_PUBKEYS
- Operator/OwnerFallback  if pubkey == RELAY_OWNER_PUBKEY AND
                          RELAY_OPERATOR_PUBKEYS is empty (config-evaluated only)
- Operator|Moderator/Db  from relay_operators table otherwise
- None → 403, never a fall-through role

Config outranks DB: a DB row for a config-backed Operator is ignored.
RELAY_OWNER_PUBKEY malformed is a startup ConfigError (was warn-and-ignore).
BUZZ_ADMIN_PUBKEYS deleted. Role vocabulary is operator|moderator end-to-end.

## NIP-98 method/body binding

authorize() takes method + raw_body, returns Option<AdminPrincipal>:
- Body-bearing mutations (POST/PUT/PATCH/DELETE) require the NIP-98 payload
  sha256 tag; requests without it are rejected 401 before any DB access.
- u tag built from config-derived host + full path-and-query (not inbound Host).
- Replay guard consumed only after cryptographic verification; Redis-down
  fails closed.
- token/disabled modes: no principal returned, probe advertises no capabilities.

## Schema (migration 0028)

- relay_operators table (global, no community_id): pubkey BYTEA PK, role TEXT
  CHECK(operator|moderator), added_by, created_at. Registered in
  _operator_global_tables and in the hardcoded parser list in migration.rs.
- moderation_actions.actor_authority: TEXT NOT NULL DEFAULT 'community'
  CHECK(community|relay_operator|relay_moderator). Backfills existing rows.
- moderation_reports: status CHECK extended with 'processing' for HTTP
  enforcement claim (Phase 2); active_action_id UUID column added.
- product_feedback.status: TEXT NOT NULL DEFAULT 'new'
  CHECK(new|reviewed|archived).

## Routes and probe

/probe endpoint: returns authMode, role, source, canAct, canStaff.
token/disabled modes: role=null, canAct=false, canStaff=false.
nip98 mode: role+source from resolved AdminPrincipal; canStaff=true for Operator only.

## buzz-db

relay_operators CRUD module: get, list, upsert, remove. Db methods added.
Migration count assertions updated to 28.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Renumber migration 0028_relay_operators → 0029_relay_operators to avoid
collision with 0028_long_reaction_payloads that landed on main.

Update migration count assertions in tests (28 → 29) and applied_versions
check (Some(28) → Some(29)). CHANGELOG conflict resolved: keep Unreleased
section above v0.5.6 release notes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(relay): add authenticated admin API — bearer token, NIP-98 pubkey allowlist, and disabled modes feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth Aug 7, 2026
Duncan and others added 2 commits August 7, 2026 17:25
…rchestrations, staffing endpoints

Add HTTP report-resolution executor, durable enforcement state machine,
staffing API, and corrected admin documentation.

Relay (crates/buzz-relay):
- api/admin/auth.rs: fix payload tag check to condition on raw_body.is_some()
  rather than method name — DELETE carries no body in the admin API;
  update method_has_body doc/suppress dead_code for prod path
- api/admin/mod.rs: add POST /reports/{id}/resolve, PATCH /feedback/{id},
  GET/PUT/DELETE /operators routes; ResolveReportBody with request_id
  idempotency key; source-aware operator listing; 409 on config-backed upsert/
  delete; test fixes — switch DB-dependent success-assertion tests to /probe
  (no DB dependency); add mutation route in-process tests (token mode →403,
  method/body substitution →401, missing payload tag →401, config-backed
  PUT/DELETE →409, owner-fallback B upsert →409); 5 ignored e2e acceptance
  test stubs (racing moderators, same-request_id retry, community 9044 vs
  processing, cancel after mutation, worker crash re-drive)
- handlers/report_resolution.rs: two orchestrations — resolve_report_decision_only
  (HTTP dismiss/escalate + 9044 adapter, single-transaction CAS open→terminal
  with audit row) and resolve_report_with_enforcement (HTTP delete/kick/ban/
  timeout, full claim/enforce/finalize state machine)
- handlers/mod.rs: register report_resolution module

buzz-db (crates/buzz-db):
- relay_admin_actions.rs: claim_report (CAS open→processing with decision audit
  row + action record in one transaction, idempotent by request_id), begin_
  enforcing, commit_mutation_step, finalize_success (action→succeeded +
  report→resolved atomically), record_failure (pre-mutation only, step_marker IS
  NULL guard), cancel_action (pre-mutation only, clears claim → report back to
  open), deploy_kick_member (deployment-authority primitive; distinguishes
  Removed from AlreadyGone, never blanket-converts MemberNotFound to success),
  update_feedback_status, outbox enqueue/mark_delivered/list_pending
- lib.rs: expose all relay_admin_actions functions on Db

migrations:
- 0030_relay_admin_actions.sql: relay_admin_actions + relay_admin_outbox tables,
  both registered in _operator_global_tables

docs:
- docs/admin/README.md: rewrite NIP-98 section to document actual OPERATOR/
  MODERATOR principal model; remove deleted BUZZ_ADMIN_PUBKEYS allowlist
  references; add principal resolution table, capabilities table, staffing
  section, complete Routes section with mutation/staffing endpoints

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
report_detail_rejects_unknown_report and
feedback_attachment_rejects_unknown_feedback both issue SQL queries
against Postgres which is unavailable in unit tests.  Without a DB
the relay returns 500 instead of 404, causing false failures in CI.
Mark both with #[ignore = "requires Postgres"] to match the
established pattern for DB-dependent admin tests on this branch.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Duncan and others added 10 commits August 7, 2026 18:12
…rker, CAS fences, tests

- Outbox rows now inserted inside claim_report transaction (atomicity guarantee:
  reporter_notice/tombstone/system_message rows exist even on process crash before
  request path re-enqueues them)
- Add claim_pending_outbox_batch (SELECT FOR UPDATE SKIP LOCKED, lease-based),
  fail_outbox_row DB functions; wire Db wrapper methods
- Implement admin_outbox_worker: background task polls relay_admin_outbox with
  DB-level leases (held_by/lease_expires_at), drives tombstone/system_message/
  reporter_notice delivery; spawned from main.rs unconditionally
- drive_enforcement rewritten as loop (eliminates recursive async fn compile error;
  CAS contention reloads + continues rather than recursing)
- Fix remaining clippy lints: type_complexity on decode_report_target (module-level
  TargetPair alias), single_match in config.rs test (-> if let)
- Replace 5 todo!() acceptance test placeholders with real Postgres-backed
  implementations; add 8 additional DB-layer tests in relay_admin_actions::tests
  covering: racing moderators, idempotent retry, cancel-post-mutation rejection,
  crash re-drive from step_marker, decision-only no-orphan-audit, outbox-in-claim,
  finalize-without-marker rejection, kick Removed vs AlreadyGone
- Purge BUZZ_ADMIN_PUBKEYS from .env.example, deploy/compose/.env.example,
  CHANGELOG.md, crates/buzz-relay/CHANGELOG.md; replace with RELAY_OPERATOR_PUBKEYS
  + fallback B + relay_operators table description
- Revert unrelated mobile/pubspec.lock drift

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ues, relay_operator authority

Test setup used 16-byte UUID slices for report_event_id (constraint requires 32),
'public_group' for channel_type (not a valid enum value; use 'stream'), 'public'
for channel visibility (use 'open'), and 'config' for actor_authority in
moderation_actions inserts (constraint allows 'community'|'relay_operator'|
'relay_moderator'; 'config' is a source label, not an authority level).

All 9 relay_admin_actions DB-layer tests now pass on a fresh Postgres instance.
The 5 acceptance tests in api::admin also pass (requires DATABASE_URL set to
the test DB). 6 pre-existing bridge test failures (Redis-gated) unchanged from
origin/main baseline.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Incorporates desktop v0.5.7 release notes. Resolves CHANGELOG.md conflict:
Unreleased relay auth section preserved above new v0.5.7 entry.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…workers

Add end-to-end state-machine tests as required by Thufir's pass-2
IMPORTANT-2 finding: acceptance tests must drive handle_resolve,
drive_enforcement, and the workers, not just DB functions.

The 6 new tests (all #[ignore = "requires Postgres"]):

1. delete_then_crash_before_tombstone_redrive — claim+ban+marker, simulate
   crash, re-drive via drive_enforcement_pub, verify succeeded+resolved+
   outbox rows; idempotent second re-drive creates no duplicates.

2. kick_retry_after_this_action_removed_member — action1 kicks (Removed+
   marker), re-drive succeeds; action2 on same absent target returns
   EnforcementFailed (AlreadyGone provenance path).

3. delivery_failure_leaves_report_resolved_with_retryable_delivery_state —
   full ban finalization, then exhaust OUTBOX_MAX_ATTEMPTS; report stays
   resolved, outbox row transitions to failed.

4. lease_expiry_action_takeover_by_worker — stranded action with artificially
   expired lease, claim_stranded_action_batch finds it, drive_enforcement_pub
   with held lease converges it; second batch finds nothing.

5. success_gated_artifacts_nothing_published_before_enforcement_succeeds —
   zero outbox rows after claim and after mutation+marker, positive count only
   after finalize_success; exercises resolve_report_with_enforcement path.

6. community_9044_through_actual_adapter_against_processing_report — calls
   resolve_report_decision_only against a processing report, asserts
   ResolutionError::NotOpen, verifies no orphan audit row.

Also fix test fixture bugs found on first Postgres execution:
- target_kind must be 'pubkey' when target_pubkey is set (constraint
  moderation_reports_check requires pubkey IS NULL when kind='event')
- batch_size in claim_stranded_action_batch raised to 1000 to avoid
  stranded actions from prior test runs filling the batch window

Fix drive_enforcement / drive_enforcement_pub to accept held_lease:
Option<Uuid>. When the action recovery worker holds a batch-claim lease,
passing it bypasses acquire_action_lease — previously the batch's
120-second lease caused the driver to spin in the Contended loop for
two minutes before the lease expired. The production worker passes
Some(claim.lease_token); HTTP and test re-drives pass None.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…boundary

Address all five pass-3 findings from Thufir's review:

C1 — Make the action lease a real fencing token:
- All four execute_*_with_marker functions now SELECT EXISTS on
  action_lease_token = expected AND action_lease_expires_at > now()
  before touching domain rows, and also bind the token in the
  marker UPDATE's WHERE clause. A stale token causes rollback before
  any domain row is written.
- claim_stranded_action_batch assigns a unique token per row via
  individual UPDATE statements so per-row fencing in execute_*_with_marker
  is correct across batches.
- drive_enforcement now has a bounded HTTP contention wait:
  30 × 100 ms = 3 s max, then returns a retryable error.

C2 — Fence outbox completion and retry on ownership:
- claim_pending_outbox_batch assigns a unique outbox_claim_token per
  row (new migration 0032).
- mark_outbox_delivered fences on outbox_claim_token = expected AND
  state = 'pending' — zero rows updated means ownership lost.
- fail_outbox_row is one atomic UPDATE with attempt_count + 1 and
  CASE-derived state/retry_after, fenced by outbox_claim_token.
- OutboxRecord carries claim_token; deliver_one passes it through.

C3 — Durable-persistence completion boundary for delivery:
- emit_system_message now propagates insert failure instead of
  swallowing it. The idempotency_ts parameter (outbox row created_at)
  gives DB-enforced idempotency via ON CONFLICT DO NOTHING on
  the stable Nostr event ID.

I1 — Bounded HTTP lease-contention wait (3 s / 30 attempts).

I2 — Tests through the real boundaries:
- delete_then_crash_before_tombstone_redrive: DELETE + tombstone,
  recover_one entry point.
- delivery_failure: deliver_one via real claim path.
- lease_expiry_action_takeover: recover_one (not drive_enforcement_pub).
- community_9044: handle_resolve / command dispatch.
- stale_action_lease_token_rejected_at_mutation: C1 race test.
- stale_outbox_claim_token_rejected_on_completion: C2 race test.
- failed_system_message_insert_not_marked_delivered: C3 race test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… test

drive_enforcement_pub requires an expired (or absent) lease before it
can re-acquire one. The kick-provenance test held lease_token2 on action2
while calling drive_enforcement_pub with held_lease=None, causing the
3-second contention timeout. Expire the lease (back-date to -300s) before
the driver call — matching the production scenario where the lease times
out before the recovery worker re-drives.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main:
  chore(release): release Buzz Desktop version 0.5.8 (#5326)
  fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds (#5318)
  Revert "fix(acp): reject unattended permission requests" (#5323)
  feat(desktop): unify add agent flows (#5015)
  fix(buzz-agent): budget summarizer reasoning separately so it cannot starve the handoff summary (#5248)
  infra: bind development services to loopback (#4871)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>

# Conflicts:
#	CHANGELOG.md
Migration 0032_relay_admin_outbox_claim_token.sql adds a new file;
the migrator-count gate in embedded_migrator_contains_consolidated_initial_schema
was asserting 31 and now correctly asserts 32.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…arer-auth

* origin/main:
  chore(release): release Buzz Relay version 0.2.1 (#2856)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>

# Conflicts:
#	crates/buzz-relay/CHANGELOG.md
…-lost outcome, test boundaries

C3-residual: make reporter notices concurrency-safe by building the event
deterministically from the outbox row's immutable created_at (idempotency_ts
parameter). Two workers racing the same row produce byte-identical Nostr events;
insert_event's ON CONFLICT DO NOTHING then guarantees exactly one durable notice.
Removes the stale query-then-insert pre-check and its 'not concurrency-safe' comment.

C1-liveness: add MutationOutcome enum (Committed / AlreadyCommitted / LeaseLost).
run_atomic_mutation classifies Ok(false) by reloading the action row: step_marker
set -> AlreadyCommitted (safe to advance); step_marker NULL -> LeaseLost.
drive_enforcement returns Err on LeaseLost so recover_one terminates rather than
looping with the expired token.

I2 test boundaries:
- lease_expiry_action_takeover_by_worker: Phase 1 calls drive_enforcement_pub with
  an expired caller-supplied token and asserts Err containing 'lease lost'. Phase 2
  re-claims and converges via recover_one.
- failed_system_message_insert_not_marked_delivered: arms the buzz.created_at_floor
  GUC and backdates the outbox row's created_at so the deferrable replica-fence
  trigger fires inside insert_event after tenant resolution, proving the ? in
  emit_system_message propagates to fail_outbox_row.
- delete_then_crash_before_tombstone_redrive: extended with Step 3 that calls
  deliver_one on the tombstone row, then asserts the kind:40099 event is durably
  persisted, the outbox row is delivered, and the target event has deleted_at set.
- reporter_notice_duplicate_delivery_persists_exactly_one: new test drives two
  sequential deliver_one calls on the same logical notice row and asserts exactly
  one kind:9 event with the moderation_source tag is durable.

MINOR: correct both batch-claim helpers' comments about FOR UPDATE SKIP LOCKED.
The locks end with the SELECT statement; the UPDATE's own WHERE clause provides
the re-verification, not an implicit retained lock.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-ready Appropriate for agentic review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants